<?php

/**
 * MNet Library 
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    mnet
 * @subpackage core
 * @author     Catalyst IT Ltd
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
 * @copyright  (C) portions from Moodle, (C) Martin Dougiamas http://dougiamas.com
 *
 */


/**
 * This class represents an XML message between peers. A message
 * may or may not be signed and encrypted.
 * 
 * Typically a message is a wrapped XML-RPC call or response.
 * 
 * Various other classes extend this class to do more specific
 * things
 */
class mnet_message {
    protected $xml;   // string
    protected $state;
    protected $xml_errors;

    const STATE_CLEARTEXT = 'CLEARTEXT';
    const STATE_SIGNED    = 'SIGNED';
    const STATE_ENCRYPTED = 'ENCRYPTED';
    const STATE_INVALID   = 'INVALID';

    /**
	 * Construct a new message, providing some valid XML as a string.
	 *
	 * The class will automatically detect whether the XML is an encrypted
	 * message or not.
     */
    public function __construct($xml) {
        $this->xml = $xml;
        $this->detect_current_state();
    }

    /**
	 * Returns the current state of the message. One of:
	 * mnet_message::STATE_CLEARTEXT if the message is plain, unwrapped XML
	 * mnet_message::STATE_SIGNED if the message is XML wrapped in an XML sig
	 * mnet_message::STATE_ENCRYPTED if the message is XML wrapped in XML enc
	 * mnet_message::STATE_INVALID if the message is not valid XML
	 * 
	 * @return int the current state of the wrapped XML document
     */
    public function get_state() {
        return $this->state;
    }

    /**
     * Returns the currently stored XML, whether plaintext or otherwise
     * 
     * @return string the stored XML, which may be cleartext, signed, or encrypted
     * @see get_state() to get the current state of this string
     */
    public function get_xml() {
        return $this->xml;
    }
    
    /**
     * Returns the current message as a SimpleXMLElement ready for
     * traversing.
     * 
     * @return SimpleXMLElement the current message state
     * @see get_state() to get the current state of the message
     */
    public function get_simplexml() {
        if ($this->state == self::STATE_INVALID) {
            // XML is invalid, won't try to parse
            throw new mnet_message_invalid_request('Invalid XML: '.$this->xml);
        }

        return new SimpleXMLElement($this->xml);
    }

    /**
     * If the XML doesn't validate, this will return the errors
     * 
     * Should only be called if the message is in STATE_INVALID
     * 
     * @return string a description of the current errors with this document
     */
    public function get_xml_error() {
        $message = 'XML validation error: '.PHP_EOL;
        foreach ($this->xml_errors as $err) {
            $message .= 'Line '.$err->line.' Column '.$err->column.': '.$err->message.PHP_EOL;
        }

        return $message;
    }

    /**
	 * Detects and updates the current state of the stored XML
     */
    private function detect_current_state() {
        // Validate the XML
        libxml_use_internal_errors(true);
        $doc = new DOMDocument('1.0', 'utf-8');
        $doc->loadXML($this->xml);
        $this->xml_errors = libxml_get_errors();
        libxml_use_internal_errors(false);

        if (!empty($this->xml_errors)) {
            $this->state = self::STATE_INVALID;
        }
        else if (strpos($this->xml, '<signedMessage>') !== false) {
            $this->state = self::STATE_SIGNED;
        }
        else if (strpos($this->xml, '<encryptedMessage>') !== false) {
            $this->state = self::STATE_ENCRYPTED;
        }
        else {
            $this->state = self::STATE_CLEARTEXT;
        }
    }

    /**
	 * If the message is currently cleartext, this will wrap it into
	 * an XML-Signature document and sign it with the provided private key
	 *
	 * This function can sign any content, but it was written to provide a system of
     * signing XML-RPC request and response messages. The message will be base64
     * encoded, so it does not need to be text.
     *
     * We compute the SHA1 digest of the message.
     * We compute a signature on that digest with our private key.
     * We link to the public key that can be used to verify our signature.
     * We base64 the message data.
     * We identify our wwwroot - this must match our certificate's CN
     *
     * The XML-RPC document will be parceled inside an XML-SIG document, which holds
     * the base64_encoded XML as an object, the SHA1 digest of that document, and a
     * signature of that document using the local private key. This signature will
     * uniquely identify the RPC document as having come from this server.
     *
     * See the {@Link http://www.w3.org/TR/xmldsig-core/ XML-DSig spec} at the W3c
     * site
     * 
     * @param string $privatekey The private key to sign the message with (PEM string)
     * @throws mnet_message_invalid_request if asked to sign something that's not cleartext
     * @throws mnet_message_encryption_error if OpenSSL fails to sign the XML
     */
    public function sign($privatekey) {
        global $MNET, $MNET_APPLICATION;

        if ($this->state != self::STATE_CLEARTEXT) {
            throw new mnet_message_invalid_request("Message is not in the cleartext state so shouldn't be signed");
        }

        $privatekey = $this->prepare_private_key($privatekey);

        $digest = sha1($this->xml);

        // The '$sig' value below is returned by reference.
        $sig = null;
        $success = openssl_sign($this->xml, $sig, $privatekey);
        if (!$success) {
            throw new mnet_message_encryption_error('Signing failed, check private key resource is valid');
        }

        $this->xml = '<?xml version="1.0" encoding="iso-8859-1"?>
        <signedMessage>
            <Signature Id="MNetSignature" xmlns="http://www.w3.org/2000/09/xmldsig#">
                <SignedInfo>
                    <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
                    <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#dsa-sha1"/>
                    <Reference URI="#XMLRPC-MSG">
                        <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
                        <DigestValue>'.$digest.'</DigestValue>
                    </Reference>
                </SignedInfo>
                <SignatureValue>'.base64_encode($sig).'</SignatureValue>
                <KeyInfo>
                    <RetrievalMethod URI="'.htmlspecialchars($MNET_APPLICATION->get_public_key_url()).'"/>
                </KeyInfo>
            </Signature>
            <object ID="XMLRPC-MSG">'.base64_encode($this->xml).'</object>
            <wwwroot>'.htmlspecialchars($MNET->get_local_wwwroot()).'</wwwroot>
            <timestamp>'.time().'</timestamp>
            <mnet-protocol>2.0</mnet-protocol>
            <server-path>'.htmlspecialchars($MNET_APPLICATION->get_server_path()).'</server-path>
        </signedMessage>';

        $this->detect_current_state();
    }

    /**
     * If the message is currently signed, this will progress it to the encrypted state
     *
     * This function can encrypt any content, but it was written to provide a system
     * of encrypting XML-RPC request and response messages. The message will be
     * base64 encoded, so it does not need to be text - binary data should work.
     *
     * We take the remote endpoint's public key
     * We perform an OpenSSL "seal" operation, which randomly creates a new random secret key encrypted with their public key
     * We wrap both the encrypted result and the encrypted secret key (both base64-encoded) into an XML-ENC document for sending
     * We identify our wwwroot - this must match our certificate's CN
     * 
     * The client can decrypt this by decrypting the secret key using their private key, then use the secret
     * key to unwrap the message (see mnet_xmlrpc_encparser)
     *
     * See the {@Link http://www.w3.org/TR/xmlenc-core/ XML-ENC spec} at the W3c
     * site
     *
     * @param string $publickey the public key to use for encryption (X.509 certificate string)
     * @throws mnet_message_invalid_request if asked to encrypt something that's not signed
     * @throws mnet_message_encryption_error if OpenSSL fails to encrypt the XML
     */
    public function encrypt($publickey) {
        global $MNET, $MNET_APPLICATION;

        $publickey = $this->prepare_public_key($publickey);

        // Initialize vars
        $encryptedstring = '';
        $symmetric_keys = array();

        //        passed by ref ->     &$encryptedstring &$symmetric_keys
        $success = openssl_seal($this->xml, $encryptedstring, $symmetric_keys, array($publickey));
        if (!$success) {
            throw new mnet_message_encryption_error('Failed to seal the message (unknown reason)');
        }

        $message = $encryptedstring;
        $symmetrickey = array_pop($symmetric_keys);

        $this->xml = '<?xml version="1.0" encoding="iso-8859-1"?>
        <encryptedMessage>
            <EncryptedData Id="ED" xmlns="http://www.w3.org/2001/04/xmlenc#">
                <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#arcfour"/>
                <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                    <ds:RetrievalMethod URI="#EK" Type="http://www.w3.org/2001/04/xmlenc#EncryptedKey"/>
                    <ds:KeyName>XMLENC</ds:KeyName>
                </ds:KeyInfo>
                <CipherData>
                    <CipherValue>'.base64_encode($message).'</CipherValue>
                </CipherData>
            </EncryptedData>
            <EncryptedKey Id="EK" xmlns="http://www.w3.org/2001/04/xmlenc#">
                <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
                <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                    <ds:KeyName>SSLKEY</ds:KeyName>
                </ds:KeyInfo>
                <CipherData>
                    <CipherValue>'.base64_encode($symmetrickey).'</CipherValue>
                </CipherData>
                <ReferenceList>
                    <DataReference URI="#ED"/>
                </ReferenceList>
                <CarriedKeyName>XMLENC</CarriedKeyName>
            </EncryptedKey>
            <wwwroot>'.htmlspecialchars($MNET->get_local_wwwroot()).'</wwwroot>
        </encryptedMessage>';

        $this->detect_current_state();
    }

    /**
     * If the message is encrypted, this will attempt to decrypt it
     * 
     * @param string $private_key the private key (PEM format string)
     * @throws mnet_message_invalid_request if asked to decrypt something that's not encrypted
     * @throws mnet_message_encryption_error if OpenSSL fails to decrypt the XML (including because the wrong key was used)
     */
    public function decrypt($private_key) {
        if ($this->state != self::STATE_ENCRYPTED) {
            throw new mnet_message_invalid_request("Attempt to decrypt a message that's not encrypted");
        }

        $private_key = $this->prepare_private_key($private_key);

        // Extract the necessary fields out of the XML
        $xml  = $this->get_simplexml();

        // Data is the encrypted data, symmetrically encrypted with the $key
        $data = (string) $xml->EncryptedData->CipherData->CipherValue;

        // Key is the symmetric key to decrypt the payload, but the key itself is encrypted with our public key (we hope)
        $key  = (string) $xml->EncryptedKey->CipherData->CipherValue;

        if (empty($key) || empty($data)) {
            throw new mnet_message_encryption_error('Required cipher key and data fields not found');
        }

        $payload = ''; // pass in by ref
        $success = openssl_open(base64_decode($data), $payload, base64_decode($key), $private_key);
        
        if (!$success) {
            throw new mnet_message_encryption_error('Supplied private key failed to open the message');
        }

        $this->xml = $payload;

        $this->detect_current_state();
    }

    /**
     * If the message is currently in the signed state, this will check
     * the signature of payload against the provided public key
     *
     * @param string $public_key the public key to check the signature against (X.509 certificate string)
     * @return bool true if the signature matches
     * @throws mnet_message_invalid_request if asked to check a message that's not currently signed
     */
    public function check_signature($public_key) {
        if ($this->state != self::STATE_SIGNED) {
            throw new mnet_message_invalid_request('Message is not currently signed');
        }

        $public_key = $this->prepare_public_key($public_key);

        $xml = $this->get_simplexml();
        
        $payload   = (string) $xml->object;
        $signature = (string) $xml->Signature->SignatureValue;

        if (empty($payload) || empty($signature)) {
            throw new mnet_message_invalid_request('XML does not contain payload and signature. Note properly signed?');
        }
        
        $payload   = base64_decode($payload);
        $signature = base64_decode($signature);

        return openssl_verify($payload, $signature, $public_key);
    }

    /**
	 * This method simply unwraps the payload from the signature container,
	 * leaving the XML in cleartext
	 *
	 * @throws mnet_message_invalid_request if asked to unwrap a message that's not currently signed
     */
    public function strip_signature() {
        if ($this->state != self::STATE_SIGNED) {
            throw new mnet_message_invalid_request('Message is not currently signed');
        }
        
        $xml = $this->get_simplexml();
        $payload = (string) $xml->object;

        $this->xml = base64_decode($payload);

        $this->detect_current_state();
    }
    
    /**
     * Returns the wwwroot of the message author, if found in the message.
     * 
     * Note this can't be trusted until after decryption for incoming messages
     * 
     * @return string the wwwroot or null if not found
     */
    public function get_wwwroot() {
        $xml = $this->get_simplexml();
        return (string)$xml->wwwroot;
    }

    /**
     * Returns the timestamp field of the signed message, if found
     * 
     * @return int the time the server composed this message
     */
    public function get_timestamp() {
        $xml = $this->get_simplexml();
        
        return (int)$xml->timestamp;
    }

    /**
     * Returns the protocol version, if found
     * 
     * @return string the protocol revision version, or null if not found
     */
    public function get_protocol() {
        $xml = $this->get_simplexml();

        return (string)$xml->{'mnet-protocol'};
    }
    
    /**
     * Returns the server path embedded in the signature, if found
     *
     * @return string the server path (see mnet_peer for details)
     */
    public function get_server_path() {
        $xml = $this->get_simplexml();

        return (string)$xml->{'server-path'};
    }

    /**
     * Convenience method to validate and convert a private key
     * into an OpenSSL resource
     * 
     * @param string $pubkey a PEM keypair string
     * @return resource OpenSSL private key resource
     * @throws mnet_message_encryption_error if the provided key is invalid
     */
    private function prepare_private_key($privkey) {
        $privkey_res = openssl_pkey_get_private($privkey);
        if (!is_resource($privkey_res)) {
            throw new mnet_message_encryption_error('Privkey '.print_r($privkey, true).' is not a valid private key');
        }

        return $privkey_res;
    }

    /**
     * Convenience method to validate and convert a public key
     * into an OpenSSL resource
     * 
     * @param string $pubkey an X509 certificate string or PEM keypair string
     * @return resource OpenSSL public key resource
     * @throws mnet_message_encryption_error if the provided key is invalid
     */
    private function prepare_public_key($pubkey) {
        $pubkey_res = openssl_pkey_get_public($pubkey);
        if (!is_resource($pubkey_res)) {
            throw new mnet_message_encryption_error('Pubkey '.print_r($pubkey, true).' is not a valid public key');
        }

        return $pubkey_res;
    }
}

/**
 * This exception is thrown on the rare occasion that an invalid request
 * is made on a message, for example signing a non-cleartext message
 */
class mnet_message_invalid_request extends Exception {}

/**
 * This exception is thrown if an OpenSSL error occurs
 */
class mnet_message_encryption_error extends Exception {}